Skip to content

fix(python): find blocked imports by parsing, not by line prefix - #2831

Merged
chaliy merged 1 commit into
mainfrom
claude/project-thread-pxlkst
Nov 5, 2026
Merged

chaliy merged 1 commit into
mainfrom
claude/project-thread-pxlkst

Conversation

@chaliy

@chaliy chaliy commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Михайло · project thread

What changed

The embedded Monty python3 now finds import re anywhere in the program. The gate parses the code with the same ruff parser Monty runs first and walks every statement body (functions, classes, loops, if, with, try, match). Strings and comments that mention re still run.

Why

The gate only matched lines starting with import/from, so any statement prefix loaded the disabled module: pass; import re, if True: import re, def f(): import re. Found while reviewing a downstream integration (harnx), which documented pass; import re reaching re.split at ~300 MiB RSS.

Monty 0.0.19 has no __import__ or importlib, so import statements are the only loaders; a unit test pins that so a Monty upgrade that adds them fails loudly.

Before / After

Before (0.20.3 CLI):

$ bashkit -c "python3 -c 'pass; import re; print(re.split(\",\", \"a,b\"))'"
['a', 'b']
$ bashkit -c "python3 -c 'if True: import re
print(re)'"
<module 're'>

After: both exit 1 with python3: importing module 're' is disabled for security reasons (regex DoS risk). Unit test covers 10 bypass shapes, plus a negative test (re = 'import re', comment) that still runs; integration security test in python_security_tests.rs.

Risk

  • Low
  • New direct optional deps ruff_python_parser/ruff_python_ast =0.0.3, already in the tree through monty; no new crates in Cargo.lock. Code that fails to parse is passed to Monty, which reports the same SyntaxError.

Checklist

  • Tests added or updated
  • Backward compatibility considered

🤖 Generated with Claude Code

The embedded Monty python3 blocked `import re` by matching lines that
start with `import`/`from`. Any statement prefix bypassed it:
`pass; import re`, `if True: import re`, an import inside a function,
class, loop or try. Parse with the same ruff parser Monty runs first and
walk every statement body instead.
@chaliy chaliy self-assigned this Nov 5, 2026
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
bashkit 232c3ca Commit Preview URL

Branch Preview URL
Nov 05 2026, 03:25 AM

@bashkit-pr-reviewer bashkit-pr-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the re-import gate hardening (AST parse instead of line-prefix scan). The core fix is correct and well-scoped — nested/indented imports are now caught at a single choke point — but the security claim it rests on has an unverified gap (see inline).

// Monty has no `__import__` or `importlib`, so import statements are
// the only way to load a module. Pin that: if Monty adds either, the
// AST gate in `is_disabled_import` must learn about it.
let r = run(&["-c", "m = __import__('re')\nprint(m)"], None).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] The exec/eval/compile dynamic path is not pinned. The gate is purely static (single check in execute(); the old bypass "loaded the module anyway" per knowledge/log.md, so there is no runtime enforcement), and the outer AST of exec('import re') contains no Stmt::Import node, so it sails through. This test pins __import__ and importlib but not exec("import re") / compile+exec, and the existing blackbox no_exec/no_compile tests assert only !stdout.contains("hacked"), which passes whether or not the call executes. Please add a regression test such as run(&["-c", "exec('import re')\nprint(re.search('a','a'))"]]) asserting re stays unusable (or assert explicitly that exec is unavailable), so a Monty upgrade that adds/changes exec fails loudly instead of silently reopening the gate.

}
None

let parsed = ruff_python_parser::parse_module(code).ok()?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Untrusted input is fully AST-parsed before any budget is charged: the caller runs this gate and only afterwards calls consume_budget_input/work (execute(), ~lines 517-526). A multi-MB -c string or script file gets a full parse_module before accounting. Consider consuming input budget (or enforcing a length cap) before parsing.

Comment thread crates/bashkit/Cargo.toml
# Same parser Monty uses (already in the tree through monty =0.0.19). Bashkit
# parses user code with it to find `import` statements; see
# `is_disabled_import` in builtins/python.rs. Keep in step with Monty's pin.
ruff_python_parser = { version = "=0.0.3", optional = true }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Nothing enforces the "keep in step with Monty's pin" invariant: .github/dependabot.yml ignores monty/monty-types (held at 0.0.19) but not these two crates, so a Dependabot bump of ruff_python_parser/ast past Monty's bundled 0.0.3 silently skews the gate's grammar from the executor's. Skew in the wrong direction (gate rejects what Monty accepts) turns into pass-through (parse_module(...).ok()? returns None) and reopens the hole. Please add the same ignore entries for ruff_python_parser/ruff_python_ast.

@bashkit-pr-reviewer

Copy link
Copy Markdown

Review summary (head 232c3ca)

Verdict: Good fix, correctly scoped. Replacing the line-prefix scan with a real AST walk over every statement body closes the demonstrated pass; import re / if True: import re / def f(): import re bypasses at a single choke point that covers -c, scripts, and stdin. Tests (10 bypass shapes + string/comment negatives + integration) and knowledge updates (python-builtin.md, log.md) follow the project's contribution rules. No P0 findings — COMMENT, not blocking.

Findings (posted inline):

  • [P1] python-exec-import-bypass — the "only module loaders" claim is pinned for __import__/importlib but not for exec/eval/compile. The gate is static-only with no runtime hook, so exec('import re') (no Import node in the outer AST) would sail through if Monty executes it; existing no_exec/no_compile tests assert too weakly to settle it. Needs a regression test asserting re stays unusable through exec.
  • [P2] python-gate-parse-before-budget — full parse_module of untrusted code runs before consume_budget_input/work; consider charging/capping first.
  • [P2] ruff-parser-dependabot-skew — dependabot.yml holds monty at 0.0.19 but not the new ruff_python_parser/ast pins, so an independent bump silently skews the gate's grammar from the executor's (wrong-direction skew becomes pass-through via .ok()?).

Context read beyond the diff: is_disabled_import body and all callers in crates/bashkit/src/builtins/python.rs (gate at execute(), prelude prepend order, run_python/MontyRun — no runtime import hook), feature wiring in crates/bashkit/Cargo.toml, .github/dependabot.yml ignore list, AGENTS.md rules, knowledge/runtimes/python-builtin.md and knowledge/log.md.

Could not verify: no build/test run (sandbox has no cargo/network) — findings are from code reasoning. Full diff was visible (truncated: false, 6 files); nothing unreviewed.

@chaliy
chaliy merged commit 2f35ec2 into main Nov 5, 2026
54 checks passed
@chaliy
chaliy deleted the claude/project-thread-pxlkst branch November 5, 2026 03:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant